Skip to content

feat(sqlite): benchmark cold reads#4848

Merged
NathanFlurry merged 1 commit intomainfrom
04-28-feat_sqlite_benchmark_cold_reads
May 2, 2026
Merged

feat(sqlite): benchmark cold reads#4848
NathanFlurry merged 1 commit intomainfrom
04-28-feat_sqlite_benchmark_cold_reads

Conversation

@NathanFlurry
Copy link
Copy Markdown
Member

Description

Please include a summary of the changes and the related issue. Please also include relevant motivation and context.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

Please describe the tests that you ran to verify your changes.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Copy link
Copy Markdown
Member Author

NathanFlurry commented Apr 29, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions
Copy link
Copy Markdown
Contributor

Preview packages published to npm

Install with:

npm install rivetkit@pr-4848

All packages published as 0.0.0-pr.4848.30c00ff with tag pr-4848.

Engine binary is shipped via @rivetkit/engine-cli on linux-x64-musl, linux-arm64-musl, darwin-x64, and darwin-arm64. Windows users should use the release installer or set RIVET_ENGINE_BINARY.

Docker images:

docker pull rivetdev/engine:slim-30c00ff
docker pull rivetdev/engine:full-30c00ff
Individual packages
npm install rivetkit@pr-4848
npm install @rivetkit/react@pr-4848
npm install @rivetkit/rivetkit-napi@pr-4848
npm install @rivetkit/workflow-engine@pr-4848

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 2, 2026

Code Review — PR #4848: feat(sqlite): benchmark cold reads

This is a draft PR adding SQLite cold-read benchmarking infrastructure alongside several VFS performance improvements. The scope is large (~5.5k additions) but most additions are raw benchmark output artifacts in .agent/notes/.


Overview

The PR delivers multiple things at once:

  • A new kitchen-sink benchmark script (scripts/sqlite-cold-start-bench.ts) for measuring SQLite cold-start read latency
  • VFS adaptive read-ahead (AdaptiveReadAhead) to reduce get_pages round trips
  • A RecentPageTracker for preload hint collection (not yet persisted — infrastructure only)
  • Prometheus metrics for the SQLite VFS wired through ActorMetrics
  • A new GetPageRange protocol message (client-side only — no engine handler yet)
  • Removal of the JS-exposed getSqliteVfsMetrics API (replaced by Prometheus)
  • 20+ raw benchmark output files captured during development

Benchmark summary: cold wake-read went from 1,249 get_pages calls / ~20s to ~69–220 calls / 4–8s across the recorded runs. Significant improvement.


Bugs / Correctness

Indentation error in VfsConfig::from_optimization_flags (vfs.rs)

After prefetch_depth, every subsequent field is indented one extra tab level:

Self {
    cache_capacity_pages: DEFAULT_CACHE_CAPACITY_PAGES,
    prefetch_depth: if flags.read_ahead { ... },
        adaptive_prefetch_depth: DEFAULT_ADAPTIVE_PREFETCH_DEPTH,  // ← extra tab
        max_prefetch_bytes: DEFAULT_MAX_PREFETCH_BYTES,
        ...
}

This will likely not compile, or the closing brace of from_optimization_flags will close the wrong scope. Needs fixing before merge.

TypeScript indentation regressions

rivetkit-typescript/packages/rivetkit/src/common/database/mod.ts, drizzle.ts, and native-database.ts each gained an extra level of indentation when the setSqliteVfsMetricsSource calls were removed. The diff shows:

-            const db = await nativeDatabaseProvider.open(ctx.actorId);
+                const db = await nativeDatabaseProvider.open(ctx.actorId);

and similarly async close(): Promise<void> shifted one level deeper. These should revert to the correct indentation.

VfsPreloadHintRange::new silently handles inverted bounds

fn new(start_pgno: u32, end_pgno: u32) -> Self {
    Self {
        start_pgno,
        page_count: end_pgno.saturating_sub(start_pgno).saturating_add(1),
    }
}

When end_pgno < start_pgno, saturating_sub produces 0 and then saturating_add(1) returns page_count = 1, which is silently wrong. All callers currently ensure end_pgno >= start_pgno, but a debug_assert!(end_pgno >= start_pgno) would make the invariant explicit and catch future misuse.


Design Concerns

GetPageRange client-side only

EnvoyHandle::sqlite_get_page_range, SqliteRequest::GetPageRange, and handle_sqlite_get_page_range_response are fully wired on the actor/client side, but no matching handler appears in engine/packages/pegboard-envoy. Sending a GetPageRange request will hang waiting for a response that never comes. This is presumably intentional (spec is in .agent/specs/sqlite-range-page-read-protocol.md), but it should be clearly documented in the PR or gated behind a feature flag so it is not accidentally invoked by the VFS before the engine side exists.

sqlite_vfs_commit_duration_seconds_total is a CounterVec with a single label value

sqlite_vfs_commit_duration_seconds_total.with_label_values(&["total"]);
// ...
inner.sqlite_vfs_commit_duration_seconds_total
    .with_label_values(&["total"])
    .inc_by(ns_to_seconds(total_ns));

The only label value is "total", so the phase label and CounterVec are unnecessary overhead. This should be a plain Counter (matching how sqlite_vfs_commit_total is defined). The per-phase breakdown is already fully covered by sqlite_vfs_commit_phase_duration_seconds_total.

AdaptiveReadAhead threshold arithmetic uses inline magic

let depth = if self.score >= FORWARD_SCAN_SCORE_THRESHOLD + 4 {
    config.adaptive_prefetch_depth
} else {
    config.adaptive_prefetch_depth.min(config.prefetch_depth.saturating_mul(2))
};

FORWARD_SCAN_SCORE_THRESHOLD + 4 should be its own named constant (e.g. FORWARD_SCAN_FULL_DEPTH_THRESHOLD) so the intent is clear and the value is not accidentally diverged from the threshold.

push_coalesced_range rebuilds the deque on every call

let mut retained = VecDeque::new();
while let Some(existing) = ranges.pop_front() { ... }
*ranges = retained;

This always allocates a new VecDeque and moves all elements. With a budget of 16 ranges this is acceptable, but it could be noted as a known simplification if performance becomes a concern for very frequent scan commits.


Performance

RecentPageTracker::snapshot() is O(n log n) and allocates

snapshot() clones the ranges deque, then sorts hot_pages into a Vec, filters, sorts again, and collects into two Vecs. This is fine if called rarely (e.g., on actor sleep), but if it is ever called on the hot path the cost will be noticeable at large page budgets. The current code does not appear to call it on the hot path — just want to flag this for when the persist-on-sleep story lands.


Removed APIs

inspector config block removed from registry-config.json

The inspector.enabled, inspector.token, and inspector.defaultEndpoint config fields were removed. This is a potentially breaking change for any users who have configured the inspector via JSON. If this removal is intentional and complete, it should be documented in a changelog entry. If it is in-progress or accidental, it needs to be reverted.


Minor / Style

  • RecentPageTracker::hot_pages is HashMap<u32, RecentPageAccess> accessed behind RwLock<VfsState> — this is fine per CLAUDE.md since the HashMap itself is not in a Mutex.
  • Benchmark output files (20+ .txt files in .agent/notes/) add ~700 lines to this PR that are non-code. CLAUDE.md sanctions keeping them there, but they make the diff noisy. Consider committing them in a separate "chore: record benchmark artifacts" commit to keep the code changes reviewable in isolation.
  • AdaptiveReadAhead::record correctly uses score.max(0) to prevent negative scores and score.min(FORWARD_SCAN_SCORE_MAX) for the ceiling — no bug here, just confirming the bounds are handled.
  • The new tests in ws_to_tunnel_task.rs are well-structured and cover the fencing/matching/validation behaviors cleanly.

Summary

The core algorithmic work (adaptive read-ahead, recent-page tracker, Prometheus metrics wiring) is solid and the benchmark results show meaningful improvement. The main blockers before merge are:

  1. Fix the VfsConfig::from_optimization_flags indentation (likely compile error)
  2. Fix the TypeScript indentation regressions in mod.ts, drizzle.ts, native-database.ts
  3. Gate or document the incomplete GetPageRange client path
  4. Convert sqlite_vfs_commit_duration_seconds_total to a plain Counter or add additional phase labels

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 2, 2026

Code Review: PR #4848 — feat(sqlite): benchmark cold reads

Overview

This PR establishes SQLite cold-read optimization infrastructure for RivetKit actors: adaptive read-ahead with forward-scan detection, a recent-page hint tracker for preload suggestions, a new GetPageRange wire-protocol message, and Prometheus metrics for all VFS operations. It also includes a comprehensive cold-start benchmark harness and 20+ benchmark output files in .agent/notes/.


Issues

Indentation regression — VfsConfig::from_optimization_flags (vfs.rs)

The struct literal body switches from one level of indentation to two after the prefetch_depth field:

prefetch_depth: if flags.read_ahead {
    DEFAULT_PREFETCH_DEPTH
} else {
    LEGACY_PREFETCH_DEPTH
},
    adaptive_prefetch_depth: DEFAULT_ADAPTIVE_PREFETCH_DEPTH,  // extra indent
    max_prefetch_bytes: DEFAULT_MAX_PREFETCH_BYTES,             // extra indent

Remaining fields use double-tab indentation while prefetch_depth uses single-tab. This violates the hard-tabs convention in rustfmt.toml and will produce a noisy diff on the next fmt pass.


Indentation regression — mod.ts

After removing the setSqliteVfsMetricsSource call the let closed = false; ends up with an extra level of indentation:

+			const db = await nativeDatabaseProvider.open(ctx.actorId);
+				let closed = false;   // extra indent

tracing::debug! logs full page number arrays

In resolve_pages:

tracing::debug!(
    requested_pages = ?target_pgnos,
    missing_pages = ?missing,
    predicted_pages = ?predicted_pgnos,
    ...
    "vfs get_pages fetch"
);

For a 50 MiB cold read this emits arrays of thousands of page numbers at debug level. Even with the default filter off this can saturate the structured log pipeline when debug is temporarily enabled. Consider tracing::trace! for the full arrays and keep only counts at debug.


sqlite_vfs_commit_duration_seconds_total uses a labeled counter for a single label value

let sqlite_vfs_commit_duration_seconds_total = CounterVec::new(
    Opts::new(...),
    &["phase"],
)

The only label value ever used is "total". A CounterVec with a single hardcoded label wastes a lookup per observation. This should be a plain Counter (like the other single-value counters in the same struct).


DEFAULT_PREFETCH_DEPTH increased 4x without a flag guard

DEFAULT_PREFETCH_DEPTH jumps from 16 → 64 unconditionally. This is only gated behind flags.read_ahead; when read_ahead is false the code falls back to LEGACY_PREFETCH_DEPTH = 16. But when read_ahead is true (the default) existing actors immediately get 4x more speculative fetches per cache miss. For random-access workloads this increases both latency variance and egress bytes. A comment explaining the tradeoff would help reviewers, and it may warrant its own flag.


Breaking removal of getSqliteVfsMetrics() NAPI method

The JsSqliteVfsMetrics struct, get_sqlite_vfs_metrics NAPI method, and the setSqliteVfsMetricsSource TS interface method are removed without a deprecation wrapper. Any caller reading per-actor commit timing (latency dashboards, integration tests) will get a runtime error or silent undefined. The replacement (Prometheus via /gateway/<actor_id>/metrics) is a different transport and requires different auth; a migration note in the PR description would help adopters.


push_coalesced_range reorders LRU on merge

retained.push_back(VfsPreloadHintRange::new(start_pgno, end_pgno)); // always newest
*ranges = retained;

When a new range overlaps an old one, the merged result is appended to the back (newest), while non-overlapping old ranges keep their front position. With pop_front() as the eviction strategy, a recently-merged range that previously covered a long-lived scan can be treated as younger than a short range that was never touched. This is subtle; at minimum a comment explaining that merging intentionally resets the LRU position would prevent future confusion.


Missing unit tests for RecentPageTracker and AdaptiveReadAhead

The scoring logic in AdaptiveReadAhead::record and the range coalescing in push_coalesced_range have non-trivial edge cases (gap tolerance, scan tip vs last pgno, adjacent-but-non-overlapping ranges, u32::MAX boundaries). None of these appear to have unit tests in the diff. Given the CLAUDE.md convention of Rust test bodies in tests/ rather than inline modules, dedicated test files for these new types would catch regressions early.


Nits / Low Priority

  • .agent/notes/ benchmark outputs — 20+ runtime output files (SQLITE-COLD-002 through SQLITE-COLD-022) are committed. These are useful as research context but bloat the repo. Consider storing them externally (gist, comment attachment) and just linking from the spec.
  • scripts/ralph/ state filesscripts/ralph/prd.json, progress.txt, and archive/ contents appear to be agent state files unrelated to this feature. These look like an accidental inclusion.
  • registry-config.json removes inspector config schema — The entire inspector JSON Schema block is removed with no explanation in the PR description. If inspector config is being restructured elsewhere this should be called out explicitly.
  • unsafe in snapshot_preload_hintsunsafe { (*self.ctx_ptr).snapshot_preload_hints() } follows the existing VFS pattern, but each new unsafe call site should have an inline comment explaining the invariant that makes it sound (i.e., ctx_ptr is valid for the lifetime of the VFS).
  • COMPACTED_TRANSACTION_BYTES is unusedconst COMPACTED_TRANSACTION_BYTES = DEFAULT_TRANSACTION_BYTES; in the benchmark script is defined but never referenced, suggesting a planned path that isn't wired yet.

Summary

The core machinery (adaptive read-ahead, recent-page tracker, GetPageRange protocol stub, Prometheus metrics) is solid and well-structured. The main asks before merge: fix the two indentation regressions, downgrade the full-array debug logs to trace, convert the single-label CounterVec to a plain Counter, add unit tests for the new tracking logic, and clean up the accidental file inclusions.

@NathanFlurry NathanFlurry force-pushed the 04-28-docs_actors_document_manual_lifecycle_controls branch from d05d4d4 to b712711 Compare May 2, 2026 22:13
This was referenced May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant